iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Build on Google AI

《解構生命暗物質:用 AlphaGenome Atlas 破譯 98% 非編碼基因組的 30 天實戰》系列 第 25 篇

AlphaGenome Atlas 實戰 | Day 25 | 合成基因座軌跡圖 —— 用 Python 畫出 REF/ALT 對比

  • 分享至 

  • xImage
  •  

💡 導讀:一張漂亮的圖,也可能把錯誤藏起來

你已經走到 Day 25。昨天 Day 24,我們拿五個人工 DNA 位置,替同一個人工變異準備 REF 和 ALT 兩條曲線,逐點計算差異。現在試著想像:研究員拿到一串數字,最想做的事情大概是「先把它畫出來」。但如果橫軸差了一格、兩條線的單位不同,畫得再美也沒有意義。

今天的挑戰,就是把 Day 24 已驗證的人工 JSON 變成一張可以拿著數字逐點核對的 SVG 科學圖。我們保留你最初設計的學習方法:Google AI Studio 先做出看得見的 MVP → 在 VS Code 用 Python 逆向拆解每一步。這樣讀者不必面對一個空白編輯器,也能從現成圖形反問:「畫這條線究竟要哪幾行程式?」

一、核心概念:REF/ALT 共用 X 軸,Delta 必須保留正負號

Day 24 保存的人工資料如下,變異 ID chr1:110:A>G 僅為格式練習,未經 GRCh38 REF 驗證:

1-based 位置 REF proxy ALT proxy Delta = ALT − REF 繪圖觀察
108 0.20 0.20 +0.00 兩線重合
109 0.30 0.55 +0.25 ALT 人工訊號較高
110 0.40 0.10 −0.30 最大絕對差分,負號仍是負號
111 0.25 0.35 +0.10 ALT 人工訊號較高
112 0.20 0.20 +0.00 兩線重合

這裡只有 兩條原始代理軌跡 REF 和 ALT;下方的 Delta 是這兩條線相減得到的衍生面板,不是第三項獨立生物量測。

  • 座標對齊:同一個 1-based 位置才能相減;不要直接把 BED 0-based 位置視為相同座標。
  • 量綱對齊:REF/ALT 必須相同單位、模態、細胞情境。這裡的 0~1 是人工教學值,不是剪接機率或 AVI 分數。
  • 方向性:max(abs(Delta))=0.30,但被挑中的帶符號數值是 −0.30,位置 110。
  • 來源紀錄:圖表應記錄輸入的 SHA-256,以便發現混用版本;雜湊本身不會證明資料是真實實驗。

與 Day 23 的人工分數有什麼關係?

Day 23 對同一虛構 ID chr1:110:A>G 保存的 synthetic_score=-0.17,不是 Day 24/25 的 delta=-0.30。這兩個數字出自不同的人工教學設定,不能加總、轉成致病機率,或放在同一軸上互相比高低。本篇會把 Day 23 只當作來源背景,而不把其分數放到圖裡。

二、先用 Google AI Studio 做一張「看得見的成品」

  1. 開啟 Google AI Studio Build Mode,先閱讀教學包內的 AI_STUDIO_DAY25_PROMPT.txt。
  2. 讓前端畫出兩條 REF/ALT 折線、同一條位置軸,及帶零基準線的 Delta 面板。標示 110 位置與 −0.30。
  3. 確認所有顯示內容都來自五個人工固定值,並有醒目的 SYNTHETIC 和 NOT MODEL DATA。
  4. 再回頭用 Python 看 verify_inputs()、draw_svg()、draw_html()、emit_outputs(),理解 UI 背後各步的資料與繪圖規則。

AI Studio Build Mode 的前端程式不能自動當成 Python。 我們是先做介面 MVP,再用 Python 自行實作相同的行為契約。不要將真實病人資料或敏感 API Key 貼進 AI Studio 提示詞。

Google AI Studio 上的元件 Python Lab 對應函式 初學者學到什麼
人工 JSON 輸入 load_json() 認識資料來源與檔案格式
圖前驗證 verify_inputs() 每個點需對齊、相減可回算
16:9 軌跡圖與 Delta draw_svg() X/Y 映射、線段、點與正負差分
同頁數值表格 draw_html() 每個 SVG 點能回到資料列
多種格式匯出 emit_outputs() CSV/JSON/SVG/HTML 不同用途

三、真正畫圖前,先過「來源與座標」四道關卡

本篇不是再做一次 Day 24 的模型預測,而是重用 Day 24 已保存的人工結果。教學包內準備了兩份相互匹配的 JSON:

  • day24_track_delta_report.json:Day 24 合成 Delta 的衍生報告。
  • day24_synthetic_tracks.json:Day 24 原始人工 REF/ALT 數列。

程式先確保兩份資料都是 TOY_NOT_GRCH38、OFFLINE_ONLY、model_prediction=false、atlas_query_performed=false,然後逐點核對位置、REF、ALT、差分及 variant_here。還會核對原始 fixture 的實際 SHA-256 是否與 Day 24 報告聲稱的一致。只要不一致,就停止,不輸出一張可能令人誤解的圖。

SVG 畫圖的小秘密:Y 座標往下增加

螢幕 SVG 的 y=0 在最上方,所以數值越大、畫得越高,需要用「畫布底部 − 資料值 × 可畫高度」。這和高中數學課常見的 Y 軸方向相反。程式另外在底部 Delta 面板畫出水平零線:正值往上,負值往下。

圖表使用 1920×1080(16:9)、背景 #E5DEDA;下方保留一些字幕空間。這是可編輯的 SVG 科普教材,不是 Google DeepMind 官方視覺化輸出。

💻 四、今天動手做:完整 Python 離線繪圖 Lab

這次直接使用 Python 3.12 標準函式庫(json、csv、hashlib、xml.etree.ElementTree 等),不需安裝 Matplotlib 或其他套件。不用 GPU、SDK、API Key;不用讀取私人 GitHub。

下載完整教學 ZIP 後,同一資料夾會有:

Day25/
├── day25_offline_locus_plot_lab.py
├── test_day25_offline_locus_plot_lab.py
├── day24_track_delta_report.json
├── day24_synthetic_tracks.json
├── AI_STUDIO_DAY25_PROMPT.txt
├── README_Day25_Locus_Track_API_Bridge_Lab.md
└── day25_output/                 # 已生成的可比對範例輸出

以下是程式全文。儘管看起來較長,主要的生醫數學部分只有「核對五筆資料、算 X/Y、畫 REF/ALT、畫 Delta」;其他行是在幫新手把輸入錯誤擋下來,並留下日後可追蹤的輸出。

"""Day 25 | OFFLINE genomic-locus REF/ALT visualization lesson.

This reads Day 24's SYNTHETIC report and its original SYNTHETIC track fixture.
It does not call AlphaGenome, Atlas, Gemini, a network endpoint or a genome FASTA.
Only Python 3.12+ standard library modules are required for the student lab.
"""
from __future__ import annotations

import argparse
import csv
import hashlib
from html import escape
import json
import math
from pathlib import Path
import re
import xml.etree.ElementTree as ET

EVIDENCE_CLASS = "SYNTHETIC_EDUCATIONAL_DEMO"
MODE = "OFFLINE_ONLY"
ASSEMBLY = "TOY_NOT_GRCH38"
INPUT_SCHEMA = "day24.synthetic.refalt_delta.v1"
TRACK_SCHEMA = "day24.synthetic.refalt_tracks.v1"
FIGURE_SCHEMA = "day25.synthetic.locus_plot.v1"
VARIANT_RE = re.compile(r"(chr(?:[1-9]|1[0-9]|2[0-2]|X|Y|M)):([1-9][0-9]*):([ACGT])>([ACGT])\Z")
SHA_RE = re.compile(r"[0-9a-f]{64}\Z")
SVG_NS = "http://www.w3.org/2000/svg"
ET.register_namespace("", SVG_NS)


def load_json(path: str | Path, *, max_bytes: int = 1_000_000):
    """Read a bounded local fixture; NEVER download or deserialize pickle files."""
    file = Path(path)
    if not file.is_file() or file.stat().st_size > max_bytes:
        raise ValueError("Local fixture missing or exceeds 1 MB")
    try:
        return json.loads(file.read_text(encoding="utf-8"), parse_constant=lambda s: (_ for _ in ()).throw(ValueError("Non-finite JSON is invalid")))
    except (UnicodeError, json.JSONDecodeError) as exc:
        raise ValueError("Invalid local UTF-8 JSON") from exc


def sha256_file(path: str | Path) -> str:
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()


def _exact(record, key, expected):
    if key not in record or type(record[key]) is not type(expected) or record[key] != expected:
        raise ValueError(f"Incorrect synthetic input contract: {key}")


def _finite_unit_number(value):
    return type(value) in (float, int) and math.isfinite(value) and 0 <= value <= 1


def verify_inputs(report, track, *, track_sha=None):
    """Reject model, patient, malformed or mismatched data before plotting."""
    if not isinstance(report, dict) or not isinstance(track, dict):
        raise ValueError("Day24 report and track must be JSON objects")
    contract = {
        "schema_version": INPUT_SCHEMA, "evidence_class": EVIDENCE_CLASS,
        "mode": MODE, "assembly": ASSEMBLY,
        "track_label": "ARTIFICIAL_SIGNAL_PROXY",
        "track_unit": "UNIT_INTERVAL_0_TO_1_TOY",
        "biosample": "TOY_CELL_CONTEXT_NOT_REAL_TISSUE",
        "coordinate_system": "1-based positions", "delta_definition": "ALT_proxy - REF_proxy",
        "mechanism_status": "HYPOTHESIS_ONLY_NOT_VALIDATED",
        "model_prediction": False, "official_atlas_data": False,
        "reference_allele_verified": False, "clinical_interpretation": False,
        "atlas_query_performed": False, "real_api_calls_this_run": 0,
    }
    for key, expected in contract.items():
        _exact(report, key, expected)
    track_contract = {
        "schema_version": TRACK_SCHEMA, "evidence_class": EVIDENCE_CLASS,
        "mode": MODE, "assembly": ASSEMBLY,
        "track_label": "ARTIFICIAL_SIGNAL_PROXY",
        "track_unit": "UNIT_INTERVAL_0_TO_1_TOY",
        "biosample": "TOY_CELL_CONTEXT_NOT_REAL_TISSUE",
        "coordinate_system": "1-based positions", "model_prediction": False,
        "official_atlas_data": False, "real_api_calls_this_run": 0,
    }
    for key, expected in track_contract.items():
        _exact(track, key, expected)
    variant_id = report.get("variant_id")
    m = VARIANT_RE.fullmatch(variant_id) if isinstance(variant_id, str) else None
    if not m or m.group(3) == m.group(4) or track.get("variant_id") != variant_id:
        raise ValueError("Invalid or mismatched synthetic SNV")
    variant_pos = int(m.group(2))
    rows = report.get("point_rows")
    pos, refs, alts = (track.get(k) for k in ("positions_1based", "ref", "alt"))
    if not all(isinstance(v, list) for v in (rows, pos, refs, alts)):
        raise ValueError("Point arrays must be lists")
    if not (2 <= len(rows) <= 1000 and len(rows) == len(pos) == len(refs) == len(alts)):
        raise ValueError("Point arrays must be equally sized, 2-1000 points")
    if type(report.get("point_count")) is not int or report["point_count"] != len(rows):
        raise ValueError("Day24 point_count is incorrect")
    for i, (row, p, ref, alt) in enumerate(zip(rows, pos, refs, alts)):
        if not isinstance(row, dict) or type(p) is not int or p < 1:
            raise ValueError("Malformed position")
        if i and p != pos[i - 1] + 1:
            raise ValueError("Nonconsecutive 1-based positions")
        if not (_finite_unit_number(ref) and _finite_unit_number(alt)):
            raise ValueError("Invalid or non-finite proxy value")
        expected = {
            "position_1based": p, "ref_proxy": ref, "alt_proxy": alt,
            "delta_alt_minus_ref": round(alt - ref, 3),
            "variant_here": p == variant_pos,
        }
        for k, v in expected.items():
            if k not in row or type(row[k]) is not type(v) or row[k] != v:
                # int vs float is allowed for scientifically identical proxy values only
                if k in ("ref_proxy", "alt_proxy", "delta_alt_minus_ref") and _finite_unit_number(row.get(k)) and abs(row[k] - v) <= 1e-9:
                    continue
                raise ValueError(f"Day24 row {i} does not match fixture: {k}")
    if not (pos[0] <= variant_pos <= pos[-1]):
        raise ValueError("Variant outside plotted interval")
    peak_i = max(range(len(rows)), key=lambda i: abs(rows[i]["delta_alt_minus_ref"]))
    _exact(report, "largest_absolute_delta_position_1based", pos[peak_i])
    if type(report.get("largest_absolute_delta")) not in (float, int) or report["largest_absolute_delta"] != rows[peak_i]["delta_alt_minus_ref"]:
        raise ValueError("Largest signed delta incorrectly recorded")
    provenance = report.get("input_provenance")
    if not isinstance(provenance, dict) or not isinstance(provenance.get("track_fixture"), dict):
        raise ValueError("Day24 provenance required")
    reference = provenance["track_fixture"]
    if not isinstance(reference.get("filename"), str) or not SHA_RE.fullmatch(str(reference.get("sha256"))):
        raise ValueError("Malformed upstream track hash")
    if track_sha is not None and track_sha != reference["sha256"]:
        raise ValueError("Source track SHA-256 does not match Day24 report")
    # The score from Day23 is a different proxy. It is not plotted or mixed.
    link = report.get("day23_annotation_link")
    if link is not None:
        if not isinstance(link, dict) or link.get("variant_id") != variant_id or link.get("qc_status") != "SELECTED" or link.get("review_status") != "REVIEW_REQUIRED":
            raise ValueError("Inconsistent Day23 link")
        if link.get("score_status") not in ("MISSING_SCORE", "SYNTHETIC_SCORE"):
            raise ValueError("Day23 status invalid")
        if link["score_status"] == "MISSING_SCORE" and link.get("synthetic_score") is not None:
            raise ValueError("Day23 missing score must remain null")
        if link["score_status"] == "SYNTHETIC_SCORE" and (type(link.get("synthetic_score")) not in (int, float) or not math.isfinite(link["synthetic_score"])):
            raise ValueError("Day23 synthetic score must be finite")
    return report


def make_figure_summary(report, *, report_sha: str, track_sha: str):
    verify_inputs(report, {
        "schema_version": TRACK_SCHEMA, "evidence_class": EVIDENCE_CLASS,
        "mode": MODE, "assembly": ASSEMBLY,
        "track_label": report["track_label"], "track_unit": report["track_unit"],
        "biosample": report["biosample"], "coordinate_system": report["coordinate_system"],
        "variant_id": report["variant_id"],
        "positions_1based": [r["position_1based"] for r in report["point_rows"]],
        "ref": [r["ref_proxy"] for r in report["point_rows"]],
        "alt": [r["alt_proxy"] for r in report["point_rows"]],
        "model_prediction": False, "official_atlas_data": False,
        "real_api_calls_this_run": 0,
    }, track_sha=track_sha)
    return {
        "schema_version": FIGURE_SCHEMA,
        "evidence_class": EVIDENCE_CLASS,
        "mode": MODE,
        "assembly": ASSEMBLY,
        "variant_id": report["variant_id"],
        "coordinate_system": "1-based positions",
        "track_unit": "UNIT_INTERVAL_0_TO_1_TOY",
        "point_count": report["point_count"],
        "track_count": 2,
        "delta_definition": "ALT_proxy - REF_proxy",
        "largest_absolute_delta_position_1based": report["largest_absolute_delta_position_1based"],
        "largest_absolute_delta": report["largest_absolute_delta"],
        "day23_linked": report.get("day23_annotation_link") is not None,
        "day23_score_combined_with_delta": False,
        "source_day24_report_sha256": report_sha,
        "source_day24_track_sha256": track_sha,
        "upstream_day23_source_sha256": (report.get("input_provenance", {}).get("day23_packet") or {}).get("sha256"),
        "upstream_native_model_response_retained": False,
        "model_prediction": False,
        "official_atlas_data": False,
        "reference_allele_verified": False,
        "clinical_interpretation": False,
        "real_api_calls_this_run": 0,
        "figure_caption": "SYNTHETIC proxy signals. Five points; not actual AlphaGenome outputs or clinical evidence.",
    }


def draw_svg(report):
    """Render a single 1920×1080 16:9 figure with aligned signed-delta panel."""
    rows = report["point_rows"]
    variant_id = report["variant_id"]
    n = len(rows)
    variant_pos = int(VARIANT_RE.fullmatch(variant_id).group(2))
    root = ET.Element(f"{{{SVG_NS}}}svg", {"width": "1920", "height": "1080", "viewBox": "0 0 1920 1080", "role": "img", "aria-label": "Synthetic REF ALT genomic track with aligned delta; not model predictions"})

    def elem(tag, attrs=None, label=None, parent=None):
        child = ET.SubElement(parent if parent is not None else root, f"{{{SVG_NS}}}{tag}", {k: str(v) for k, v in (attrs or {}).items()})
        if label is not None:
            child.text = str(label)
        return child

    def text(x, y, label, size=24, fill="#444956", weight="normal", anchor=None):
        a = {"x": x, "y": y, "font-family": "Arial, sans-serif", "font-size": size, "fill": fill, "font-weight": weight}
        if anchor:
            a["text-anchor"] = anchor
        return elem("text", a, label)

    elem("rect", {"width": 1920, "height": 1080, "fill": "#E5DEDA"})
    text(108, 88, "Day 25  |  SYNTHETIC REF / ALT GENOMIC TRACKS", 49, "#323c4c", "bold")
    text(110, 133, "Same coordinates  ·  two proxy tracks  ·  one signed difference", 26, "#5d6570")
    elem("rect", {"x": 110, "y": 162, "width": 1700, "height": 66, "rx": 18, "fill": "#f6f0ed"})
    text(140, 205, variant_id, 25, "#4d5d78", "bold")
    text(530, 205, "TOY_NOT_GRCH38  ·  1-based  ·  artificial units 0–1", 23, "#65707e")
    text(1530, 205, "NOT MODEL DATA", 22, "#9d4655", "bold")
    elem("rect", {"x": 105, "y": 256, "width": 1710, "height": 392, "rx": 22, "fill": "#fffaf8"})
    elem("rect", {"x": 105, "y": 668, "width": 1710, "height": 218, "rx": 22, "fill": "#fffaf8"})
    text(150, 310, "REF / ALT proxy signal", 30, "#394457", "bold")
    text(150, 720, "Signed Delta = ALT − REF", 30, "#394457", "bold")
    left, right = 285, 1710
    top, bottom = 349, 567
    x = lambda i: left + (right - left) * i / (n - 1)
    y = lambda v: bottom - (bottom - top) * v
    for mark in (0, 0.25, 0.5, 0.75, 1.0):
        yy = y(mark)
        elem("line", {"x1": left, "y1": f"{yy:.2f}", "x2": right, "y2": f"{yy:.2f}", "stroke": "#e4e7e8", "stroke-width": 2})
        text(245, yy + 9, f"{mark:g}", 23, "#727b84", anchor="end")
    for name, field, color in (("REF", "ref_proxy", "#507d98"), ("ALT", "alt_proxy", "#c27486")):
        coords = " ".join(f"{x(i):.2f},{y(row[field]):.2f}" for i, row in enumerate(rows))
        elem("polyline", {"points": coords, "stroke": color, "stroke-width": 7, "stroke-linecap": "round", "stroke-linejoin": "round", "fill": "none", "data-track": name})
        for i, row in enumerate(rows):
            dot = elem("circle", {"cx": f"{x(i):.2f}", "cy": f"{y(row[field]):.2f}", "r": 9, "fill": color, "stroke": "#fff", "stroke-width": 2, "data-position": row["position_1based"], "data-track": name})
            elem("title", label=f"{name} position {row['position_1based']}: {row[field]:g}", parent=dot)
    for idx, (name, color) in enumerate((("REF", "#507d98"), ("ALT", "#c27486"))):
        start = 1480 + idx * 140
        elem("line", {"x1": start, "x2": start + 35, "y1": 309, "y2": 309, "stroke": color, "stroke-width": 7})
        text(start + 44, 319, name, 24, color, "bold")
    for i, row in enumerate(rows):
        if n <= 12 or i % max(1, n // 9) == 0 or i == n - 1:
            text(x(i), 612, str(row["position_1based"]), 24, "#505d6a", anchor="middle")
    pos_i = next(i for i, r in enumerate(rows) if r["position_1based"] == variant_pos)
    elem("line", {"x1": f"{x(pos_i):.2f}", "y1": 324, "x2": f"{x(pos_i):.2f}", "y2": 853, "stroke": "#a18458", "stroke-width": 3, "stroke-dasharray": "10,9", "data-variant-marker": str(variant_pos)})
    text(x(pos_i) + 16, 348, "Variant", 21, "#907248", "bold")
    deltas = [r["delta_alt_minus_ref"] for r in rows]
    scale = max(max(abs(v) for v in deltas), .1)
    zero = 784
    elem("line", {"x1": left, "y1": zero, "x2": right, "y2": zero, "stroke": "#9ea6a7", "stroke-width": 2.5})
    for i, (row, d) in enumerate(zip(rows, deltas)):
        e = zero - d / scale * 65
        if d:
            elem("line", {"x1": f"{x(i):.2f}", "y1": zero, "x2": f"{x(i):.2f}", "y2": f"{e:.2f}", "stroke": "#937993", "stroke-width": 13, "stroke-linecap": "round", "data-delta-pos": str(row["position_1based"])})
        text(x(i), 861, f"{d:+.2f}", 22, "#5d5472", anchor="middle")
    p = report["largest_absolute_delta_position_1based"]
    d = report["largest_absolute_delta"]
    text(110, 926, f"Largest absolute Delta at 1-based {p}: signed value {d:+.2f}", 27, "#384459", "bold")
    text(110, 968, "SYNTHETIC EDUCATIONAL DATA  |  No Atlas API, no genome reference validation, no clinical inference", 23, "#884f5a")
    # Deliberately leave a clear lower margin for future video captions.
    return ET.tostring(root, encoding="unicode")


def draw_html(report, summary, svg):
    """Create browser-readable offline lesson with SVG and fully escaped text."""
    h = lambda t: escape(str(t), quote=True)
    rows = "\n".join("<tr>" + "".join(f"<td>{h(r[f])}</td>" for f in ("position_1based", "ref_proxy", "alt_proxy", "delta_alt_minus_ref", "variant_here")) + "</tr>" for r in report["point_rows"])
    return f'''<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Day 25 | 合成基因座 REF/ALT 軌跡</title>
<style>body{{margin:0;background:#E5DEDA;color:#293545;font-family:system-ui, sans-serif}}main{{max-width:1240px;margin:auto;padding:25px}}.card{{background:#fffaf8;border-radius:20px;padding:22px;margin:18px 0}}svg{{width:100%;height:auto;border-radius:12px}}table{{border-collapse:collapse;width:100%}}td,th{{padding:10px;border-bottom:1px solid #e2d4d0;text-align:right}}td:first-child,th:first-child{{text-align:left}}.warning{{color:#873a51;font-weight:700}}.nav a{{margin-right:16px;color:#35587b}}</style></head>
<body><main><h1>Day 25 | 合成基因座軌跡圖</h1><p>REF 與 ALT 使用共同 1-based 位置;下方 Delta = ALT − REF,負號不可消失。</p>
<div class="card">{svg}</div>
<div class="card"><h2>逐點數值核對</h2><table><thead><tr><th>位置 1-based</th><th>REF proxy</th><th>ALT proxy</th><th>ALT − REF</th><th>變異位置?</th></tr></thead><tbody>{rows}</tbody></table></div>
<div class="card"><h2>證據說明</h2><p class="warning">SYNTHETIC_EDUCATIONAL_DEMO · TOY_NOT_GRCH38 · 0 real API calls</p>
<p>這是 Day 24 人工 JSON 的繪圖,不是 AlphaGenome Atlas/Model 的預測結果。Day 23 的人工分數不會拿來與此 Delta 加總。</p>
<p>Day24 report SHA-256: <code>{h(summary['source_day24_report_sha256'])}</code></p>
<p>Day24 track SHA-256: <code>{h(summary['source_day24_track_sha256'])}</code></p>
<p>模型機制:NOT_VALIDATED · 人類審閱:REQUIRED · 臨床解讀:不適用</p>
<p class="nav"><a href="day25_locus_track.csv">CSV 數據表</a><a href="day25_locus_summary.json">JSON 概要</a><a href="day25_locus_tracks.svg">SVG 圖</a></p></div></main></body></html>'''


def emit_outputs(report_path, source_track_path, output_dir):
    report = load_json(report_path)
    track = load_json(source_track_path)
    track_sha = sha256_file(source_track_path)
    verify_inputs(report, track, track_sha=track_sha)
    summary = make_figure_summary(report, report_sha=sha256_file(report_path), track_sha=track_sha)
    svg = draw_svg(report)
    folder = Path(output_dir)
    folder.mkdir(parents=True, exist_ok=True)
    json_path = folder / "day25_locus_summary.json"
    csv_path = folder / "day25_locus_track.csv"
    svg_path = folder / "day25_locus_tracks.svg"
    html_path = folder / "day25_locus_view.html"
    json_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    with csv_path.open("w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=["position_1based", "ref_proxy", "alt_proxy", "delta_alt_minus_ref", "variant_here"])
        writer.writeheader()
        writer.writerows(report["point_rows"])
    svg_path.write_text(svg, encoding="utf-8")
    html_path.write_text(draw_html(report, summary, svg), encoding="utf-8")
    return {
        "status": "PASS", "mode": MODE, "evidence_class": EVIDENCE_CLASS,
        "variant_id": summary["variant_id"],
        "point_count": summary["point_count"], "track_count": 2,
        "largest_absolute_delta_position_1based": summary["largest_absolute_delta_position_1based"],
        "largest_absolute_delta": summary["largest_absolute_delta"],
        "json_file": str(json_path), "csv_file": str(csv_path),
        "svg_file": str(svg_path), "html_file": str(html_path),
        "real_api_calls_this_run": 0,
    }


def main(argv=None):
    parser = argparse.ArgumentParser(description="Draw 16:9 synthetic REF/ALT locus tracks from Day24")
    parser.add_argument("--mode", choices=["offline"], required=True)
    parser.add_argument("--day24-report", default="day24_track_delta_report.json")
    parser.add_argument("--source-track", default="day24_synthetic_tracks.json")
    parser.add_argument("--output-dir", default="day25_output")
    args = parser.parse_args(argv)
    try:
        result = emit_outputs(args.day24_report, args.source_track, args.output_dir)
    except (ValueError, KeyError, OSError, TypeError) as exc:
        parser.error(str(exc))
    print(json.dumps(result, ensure_ascii=False, sort_keys=True))
    return result


if __name__ == "__main__":
    main()

▶ 執行方式(Windows/WSL)

先將 AlphaGenome_Day25_Locus_Track_API_Bridge_Author_Review_Pack.zip 下載至 Windows Downloads。在 WSL Ubuntu 終端機貼上以下 Bash 指令(不要貼到 Python 互動提示符):

cd /mnt/c/Users/user/Downloads
mkdir -p ~/alphagenome-day25-qa
python3.12 -m zipfile -e \
  AlphaGenome_Day25_Locus_Track_API_Bridge_Author_Review_Pack.zip \
  ~/alphagenome-day25-qa
cd ~/alphagenome-day25-qa

# 先測試,確保程式能發現錯位或錯誤資料
python3.12 -m unittest -v test_day25_offline_locus_plot_lab

# 再使用本 ZIP 已準備的 Day24 人工資料產圖
python3.12 day25_offline_locus_plot_lab.py \
  --mode offline \
  --day24-report day24_track_delta_report.json \
  --source-track day24_synthetic_tracks.json \
  --output-dir day25_output

預期單元測試結尾為:

Ran 52 tests

OK

這是本次打包環境的測試結果,使用者仍須在 WSL Python 3.12 自行執行確認。

▶ 預期與實際輸出(打包環境)

下方是本次執行的完整 stdout(單行 JSON,不是隨手填的想像回應),使用的是人工 Day24 來源:

{"csv_file": "day25_output/day25_locus_track.csv", "evidence_class": "SYNTHETIC_EDUCATIONAL_DEMO", "html_file": "day25_output/day25_locus_view.html", "json_file": "day25_output/day25_locus_summary.json", "largest_absolute_delta": -0.3, "largest_absolute_delta_position_1based": 110, "mode": "OFFLINE_ONLY", "point_count": 5, "real_api_calls_this_run": 0, "status": "PASS", "svg_file": "day25_output/day25_locus_tracks.svg", "track_count": 2, "variant_id": "chr1:110:A>G"}

執行後, day25_output/ 會產生:

檔案 初學者用來做什麼
day25_locus_summary.json 讀取來源 SHA、變異座標、差分及證據標籤
day25_locus_track.csv 使用 Excel 或 Python 核對每個位置的 REF、ALT、Delta
day25_locus_tracks.svg 用瀏覽器或向量編輯器打開 1920×1080 圖
day25_locus_view.html 單一 HTML 頁面,直接檢查 SVG 與五筆表格

教學包還預先附帶 day25_locus_preview.png,方便你在 Notion 先預覽;這張 PNG 是在打包環境額外由 CairoSVG 轉出,不代表主程式依賴 CairoSVG。

Windows 檔案總管路徑:

\\wsl.localhost\Ubuntu-24.04\home\user\alphagenome-day25-qa\day25_output

怎麼核對圖畫得對不對?

對照上面的五筆表格:

  • X 軸一定是 108、109、110、111、112,且顯示為 1-based。
  • REF/ALT 應有兩條線、每條五個點。
  • 位置 110 的 REF=0.40、ALT=0.10,應看到 ALT 在 REF 下方。
  • Delta 面板的 110 應位於零線下方,其標籤為 −0.30。
  • 圖中一定要看到 SYNTHETIC、TOY_NOT_GRCH38 與 NOT MODEL DATA 等標示。

✅ 本日 Checklist

  • [ ] 我能讀懂 day24_track_delta_report.json 的 point_rows,並在 CSV 對到同樣五個點。
  • [ ] 我知道 delta=ALT−REF,最大絕對差分 0.30 的帶符號數值是 −0.30。
  • [ ] 我能打開 SVG 與 HTML,找出變異位置 110 的垂直標記及兩條代理訊號。
  • [ ] 我可以解釋為何 Day 23 人工 score −0.17 不會出現在這張圖上。
  • [ ] 我能找出來源 SHA-256、TOY_NOT_GRCH38、model_prediction=false、real_api_calls_this_run=0。
  • [ ] 我知道先畫 AI Studio MVP,再用 Python 重寫的原因:看見需求、釐清資料契約、再實作可重現的程式。

五、科學證據與限制

本篇的 REF、ALT、Delta、變異 ID、坐標與細胞情境完全人工。SYNTHETIC_EDUCATIONAL_DEMO 只驗證資料契約與 SVG 可重現,不是 AlphaGenome Atlas 或 AlphaGenome Model API 的真實輸出,也不是分子機制、剪接、基因表現、臨床診斷或濕實驗證據。

真正做 REF/ALT 預測時,還必須記錄基因組組裝版本、參考鹼基、轉錄本、模型 API 輸出類型、細胞 ontology、單位、版本與來源證據。當前的 Day 24 人工資料不提供這些真實科學資訊,不能只因圖畫得漂亮就替它補寫。

已有的 Reboot 歷史 LIVE 模型預測是另一份獨立資料,本篇沒有讀取、混用或執行它;也沒有重新觸發 API。若日後加入真實軌跡,必須另案核對其輸出契約,並取得作者明確授權。

軟體 QA 與科學驗證不是同一件事。 單元測試 PASS 只表示人工資料上的軟體行為符合規格。

六、明日預告

明天 Day 26,我們會從長條軌跡改看 DNA 短序列的 Motif:用人工機率矩陣理解「字母改變,序列偏好分數怎樣變化」,並分辨 PPM-like 代理算術、真正的 log-odds PWM 與轉錄因子結合實驗。


延伸閱讀與資料


上一篇
AlphaGenome Atlas 實戰 | Day 24 | REF/ALT 軌跡差分分析 —— 從訊號變化提出機制問題
下一篇
AlphaGenome Atlas 實戰 | Day 26 | Motif 文法與變異 —— 從序列偏好理解 REF/ALT 差異
系列文
《解構生命暗物質:用 AlphaGenome Atlas 破譯 98% 非編碼基因組的 30 天實戰》 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言